--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit 5307f988528e212b76e35b487bad8c9c1acb1a65
Parents : fae620e
Author : Ivan <ivan@quad4.io>
Signature : Invalid signer <e46112d44649266d71fe2193e00a4710>, author is <ivan@quad4.io>
Date : 2026-07-10T16:33:30-05:00
feat(messages): implement message export and import functionality with contacts and read state management
Changes
22 files changed, 732 insertions(+), 68 deletions(-)
Diff
diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py
index 55fc68c5..4fe87dec 100644
--- a/meshchatx/meshchat.py
+++ b/meshchatx/meshchat.py
@@ -148,6 +148,10 @@ from meshchatx.src.backend.meshchat_utils import (
parse_nomadnetwork_node_display_name,
)
from meshchatx.src.backend.memory_pressure import MemoryPressureManager, cache_stats
+from meshchatx.src.backend.message_export_bundle import (
+ build_messages_export_bundle,
+ import_messages_export_bundle,
+)
from meshchatx.src.backend.nomadnet_downloader import (
NomadnetFileDownloader,
NomadnetPageDownloader,
@@ -8504,26 +8508,26 @@ class ReticulumMeshChat:
if len(page) < page_size:
break
offset += page_size
- icon_hashes = set()
- for m in messages_list:
- h = m.get("peer_hash") or m.get("source_hash")
- if h:
- icon_hashes.add(h)
- icons = {}
- if icon_hashes:
- icon_rows = self.database.misc.get_user_icons(list(icon_hashes))
- for ir in icon_rows:
- icons[ir["destination_hash"]] = dict(ir)
- for m in messages_list:
- h = m.get("peer_hash") or m.get("source_hash")
- if h and h in icons:
- m["lxmf_icon"] = icons[h]
- return web.json_response({"messages": messages_list})
+ bundle = await asyncio.to_thread(
+ build_messages_export_bundle,
+ self.database,
+ messages_list,
+ )
+ return web.json_response(bundle)
def _message_import_response(result):
+ if not result.get("ok", True) and result.get("error"):
+ return web.json_response(
+ {
+ "error": result["error"],
+ "imported": result.get("imported", 0),
+ "skipped": result.get("skipped", 0),
+ },
+ status=400,
+ )
imported = result["imported"]
skipped = result["skipped"]
- errors = result["errors"]
+ errors = result.get("errors") or []
if imported == 0 and errors:
return web.json_response(
{
@@ -8538,30 +8542,20 @@ class ReticulumMeshChat:
"message": f"Successfully imported {imported} messages",
"imported": imported,
"skipped": skipped,
+ "contacts_added": result.get("contacts_added", 0),
+ "contacts_skipped": result.get("contacts_skipped", 0),
+ "display_names_imported": result.get("display_names_imported", 0),
+ "read_state_imported": result.get("read_state_imported", 0),
}
if errors:
response["errors"] = errors
return web.json_response(response)
- def _parse_message_import_payload(payload):
- if isinstance(payload, list):
- return payload
- if isinstance(payload, dict):
- messages = payload.get("messages", [])
- return [] if messages is None else messages
- return None
-
# maintenance - import messages
@routes.post("/api/v1/maintenance/messages/import")
async def maintenance_import_messages(request):
try:
data = await request.json()
- messages = _parse_message_import_payload(data)
- if messages is None or not isinstance(messages, list):
- return web.json_response(
- {"error": "messages must be an array"},
- status=400,
- )
if self.database is None:
return web.json_response(
{"error": "No active identity database"},
@@ -8569,8 +8563,9 @@ class ReticulumMeshChat:
)
result = await asyncio.to_thread(
- self.database.messages.import_lxmf_messages,
- messages,
+ import_messages_export_bundle,
+ self.database,
+ data,
)
return _message_import_response(result)
except Exception as e:
@@ -8609,16 +8604,10 @@ class ReticulumMeshChat:
status=400,
)
- messages = _parse_message_import_payload(payload)
- if messages is None or not isinstance(messages, list):
- return web.json_response(
- {"error": "messages must be an array"},
- status=400,
- )
-
result = await asyncio.to_thread(
- self.database.messages.import_lxmf_messages,
- messages,
+ import_messages_export_bundle,
+ self.database,
+ payload,
)
return _message_import_response(result)
except Exception as e:
@@ -14585,7 +14574,14 @@ class ReticulumMeshChat:
@routes.post("/api/v1/lxmf/conversations/bulk-mark-as-read")
async def lxmf_conversations_bulk_mark_read(request):
data = await request.json()
+ mark_all = bool(data.get("mark_all"))
destination_hashes = data.get("destination_hashes", [])
+ if mark_all:
+ self.database.messages.mark_all_conversations_as_read()
+ self.database.messages.mark_all_notifications_as_viewed()
+ return web.json_response(
+ {"message": "All conversations marked as read"}
+ )
if not destination_hashes:
return web.json_response(
{"message": "destination_hashes is required"},
diff --git a/meshchatx/src/backend/database/announces.py b/meshchatx/src/backend/database/announces.py
index 69fd9795..d567733f 100644
--- a/meshchatx/src/backend/database/announces.py
+++ b/meshchatx/src/backend/database/announces.py
@@ -191,6 +191,11 @@ class AnnounceDAO:
(destination_hash,),
)
+ def get_all_custom_display_names(self):
+ return self.provider.fetchall(
+ "SELECT destination_hash, display_name FROM custom_destination_display_names",
+ )
+
# Favourites
def upsert_favourite(self, destination_hash, display_name, aspect):
from meshchatx.src.backend.favourite_display_names import (
diff --git a/meshchatx/src/backend/database/messages.py b/meshchatx/src/backend/database/messages.py
index e3990f14..a329db82 100644
--- a/meshchatx/src/backend/database/messages.py
+++ b/meshchatx/src/backend/database/messages.py
@@ -504,6 +504,72 @@ class MessageDAO:
(now, now, now),
)
+ def get_all_conversation_read_state(self):
+ return self.provider.fetchall(
+ "SELECT destination_hash, last_read_at, created_at, updated_at "
+ "FROM lxmf_conversation_read_state",
+ )
+
+ def import_conversation_read_state(self, rows):
+ if not isinstance(rows, list) or not rows:
+ return 0
+ imported = 0
+ now = datetime.now(UTC).isoformat()
+ with self.provider:
+ for row in rows:
+ if not isinstance(row, dict):
+ continue
+ dest = row.get("destination_hash")
+ last_read_at = row.get("last_read_at")
+ if not dest or not last_read_at:
+ continue
+ self.provider.execute(
+ """
+ INSERT INTO lxmf_conversation_read_state
+ (destination_hash, last_read_at, created_at, updated_at)
+ VALUES (?, ?, ?, ?)
+ ON CONFLICT(destination_hash) DO UPDATE SET
+ last_read_at = EXCLUDED.last_read_at,
+ updated_at = EXCLUDED.updated_at
+ """,
+ (dest, last_read_at, now, now),
+ )
+ imported += 1
+ return imported
+
+ def get_all_notification_viewed_state(self):
+ return self.provider.fetchall(
+ "SELECT destination_hash, last_viewed_at, created_at, updated_at "
+ "FROM notification_viewed_state",
+ )
+
+ def import_notification_viewed_state(self, rows):
+ if not isinstance(rows, list) or not rows:
+ return 0
+ imported = 0
+ now = datetime.now(UTC).isoformat()
+ with self.provider:
+ for row in rows:
+ if not isinstance(row, dict):
+ continue
+ dest = row.get("destination_hash")
+ last_viewed_at = row.get("last_viewed_at")
+ if not dest or not last_viewed_at:
+ continue
+ self.provider.execute(
+ """
+ INSERT INTO notification_viewed_state
+ (destination_hash, last_viewed_at, created_at, updated_at)
+ VALUES (?, ?, ?, ?)
+ ON CONFLICT(destination_hash) DO UPDATE SET
+ last_viewed_at = EXCLUDED.last_viewed_at,
+ updated_at = EXCLUDED.updated_at
+ """,
+ (dest, last_viewed_at, now, now),
+ )
+ imported += 1
+ return imported
+
def is_conversation_unread(self, destination_hash):
row = self.provider.fetchone(
"""
diff --git a/meshchatx/src/backend/message_export_bundle.py b/meshchatx/src/backend/message_export_bundle.py
new file mode 100644
index 00000000..252ca35a
--- /dev/null
+++ b/meshchatx/src/backend/message_export_bundle.py
@@ -0,0 +1,234 @@
+# SPDX-License-Identifier: 0BSD
+"""Build and restore LXMF message export bundles with contacts and read state."""
+
+from __future__ import annotations
+
+from meshchatx.src.backend.meshchat_utils import parse_lxmf_display_name
+
+MESSAGE_EXPORT_FORMAT = "meshchatx/messages/v2"
+
+
+def _contacts_for_export(database) -> list[dict]:
+ rows = database.contacts.get_contacts(limit=10000, offset=0)
+ hashes = [r["remote_identity_hash"] for r in rows if r.get("remote_identity_hash")]
+ icons = {}
+ if hashes:
+ for ir in database.misc.get_user_icons(hashes):
+ icons[ir["destination_hash"]] = dict(ir)
+ export_data = []
+ for row in rows:
+ d = dict(row)
+ d.pop("id", None)
+ h = d.get("remote_identity_hash")
+ if h and h in icons:
+ d["lxmf_icon"] = icons[h]
+ export_data.append(d)
+ return export_data
+
+
+def _display_names_for_export(database, peer_hashes: set[str]) -> list[dict]:
+ """Custom names plus announce-derived names for message peers."""
+ names: dict[str, str] = {}
+ for row in database.announces.get_all_custom_display_names():
+ dest = row.get("destination_hash")
+ name = row.get("display_name")
+ if dest and name:
+ names[dest] = name
+
+ for peer_hash in peer_hashes:
+ if peer_hash in names:
+ continue
+ announce = database.announces.get_announce_by_hash(peer_hash)
+ if not announce:
+ continue
+ try:
+ app_data = announce["app_data"]
+ except (KeyError, IndexError, TypeError):
+ continue
+ parsed = parse_lxmf_display_name(app_data, default_value=None)
+ if parsed:
+ names[peer_hash] = parsed
+
+ return [
+ {"destination_hash": dest, "display_name": name}
+ for dest, name in sorted(names.items())
+ ]
+
+
+def _read_state_for_export(database) -> list[dict]:
+ rows = database.messages.get_all_conversation_read_state()
+ out = []
+ for row in rows:
+ d = dict(row)
+ d.pop("id", None)
+ d.pop("created_at", None)
+ d.pop("updated_at", None)
+ if d.get("destination_hash") and d.get("last_read_at"):
+ out.append(d)
+ return out
+
+
+def _notification_viewed_for_export(database) -> list[dict]:
+ rows = database.messages.get_all_notification_viewed_state()
+ out = []
+ for row in rows:
+ d = dict(row)
+ d.pop("id", None)
+ d.pop("created_at", None)
+ d.pop("updated_at", None)
+ if d.get("destination_hash") and d.get("last_viewed_at"):
+ out.append(d)
+ return out
+
+
+def build_messages_export_bundle(database, messages_list: list[dict]) -> dict:
+ peer_hashes: set[str] = set()
+ icon_hashes: set[str] = set()
+ for m in messages_list:
+ h = m.get("peer_hash") or m.get("source_hash")
+ if h:
+ peer_hashes.add(h)
+ icon_hashes.add(h)
+
+ icons = {}
+ if icon_hashes:
+ for ir in database.misc.get_user_icons(list(icon_hashes)):
+ icons[ir["destination_hash"]] = dict(ir)
+ for m in messages_list:
+ h = m.get("peer_hash") or m.get("source_hash")
+ if h and h in icons:
+ m["lxmf_icon"] = icons[h]
+
+ return {
+ "format": MESSAGE_EXPORT_FORMAT,
+ "messages": messages_list,
+ "contacts": _contacts_for_export(database),
+ "display_names": _display_names_for_export(database, peer_hashes),
+ "conversation_read_state": _read_state_for_export(database),
+ "notification_viewed_state": _notification_viewed_for_export(database),
+ }
+
+
+def _import_contacts(database, contacts) -> tuple[int, int]:
+ if not isinstance(contacts, list):
+ return 0, 0
+ seen = {}
+ no_hash = []
+ for c in contacts:
+ if not isinstance(c, dict):
+ continue
+ h = c.get("remote_identity_hash")
+ if h:
+ seen[h] = c
+ else:
+ no_hash.append(c)
+ unique_contacts = list(seen.values()) + no_hash
+ added = 0
+ skipped = 0
+ for c in unique_contacts:
+ name = c.get("name")
+ remote_identity_hash = c.get("remote_identity_hash")
+ if not name or not remote_identity_hash:
+ skipped += 1
+ continue
+ try:
+ database.contacts.add_contact(
+ name,
+ remote_identity_hash,
+ lxmf_address=c.get("lxmf_address"),
+ lxst_address=c.get("lxst_address"),
+ preferred_ringtone_id=c.get("preferred_ringtone_id"),
+ custom_image=c.get("custom_image"),
+ is_telemetry_trusted=c.get("is_telemetry_trusted", 0),
+ )
+ icon = c.get("lxmf_icon")
+ if isinstance(icon, dict) and icon.get("icon_name"):
+ database.misc.update_lxmf_user_icon(
+ remote_identity_hash,
+ icon.get("icon_name"),
+ icon.get("foreground_colour"),
+ icon.get("background_colour"),
+ )
+ added += 1
+ except Exception:
+ skipped += 1
+ return added, skipped
+
+
+def _import_display_names(database, display_names) -> int:
+ if not isinstance(display_names, list):
+ return 0
+ count = 0
+ for row in display_names:
+ if not isinstance(row, dict):
+ continue
+ dest = row.get("destination_hash")
+ name = row.get("display_name")
+ if not dest or not name:
+ continue
+ database.announces.upsert_custom_display_name(dest, name)
+ count += 1
+ return count
+
+
+def import_messages_export_bundle(database, payload) -> dict:
+ """Import messages plus optional contacts, names, and read state.
+
+ Accepts legacy ``{messages: [...]}`` / bare arrays and v2 bundles.
+ """
+ if isinstance(payload, list):
+ messages = payload
+ contacts = []
+ display_names = []
+ read_state = []
+ viewed_state = []
+ elif isinstance(payload, dict):
+ messages = payload.get("messages", [])
+ if messages is None:
+ messages = []
+ contacts = payload.get("contacts") or []
+ display_names = payload.get("display_names") or []
+ read_state = payload.get("conversation_read_state") or []
+ viewed_state = payload.get("notification_viewed_state") or []
+ else:
+ return {
+ "ok": False,
+ "error": "messages must be an array",
+ "imported": 0,
+ "skipped": 0,
+ "errors": [],
+ "contacts_added": 0,
+ "contacts_skipped": 0,
+ "display_names_imported": 0,
+ "read_state_imported": 0,
+ }
+
+ if not isinstance(messages, list):
+ return {
+ "ok": False,
+ "error": "messages must be an array",
+ "imported": 0,
+ "skipped": 0,
+ "errors": [],
+ "contacts_added": 0,
+ "contacts_skipped": 0,
+ "display_names_imported": 0,
+ "read_state_imported": 0,
+ }
+
+ contacts_added, contacts_skipped = _import_contacts(database, contacts)
+ display_names_imported = _import_display_names(database, display_names)
+ msg_result = database.messages.import_lxmf_messages(messages)
+ read_imported = database.messages.import_conversation_read_state(read_state)
+ viewed_imported = database.messages.import_notification_viewed_state(viewed_state)
+
+ return {
+ "ok": True,
+ "imported": msg_result["imported"],
+ "skipped": msg_result["skipped"],
+ "errors": msg_result["errors"],
+ "contacts_added": contacts_added,
+ "contacts_skipped": contacts_skipped,
+ "display_names_imported": display_names_imported,
+ "read_state_imported": read_imported + viewed_imported,
+ }
diff --git a/meshchatx/src/frontend/components/messages/MessagesPage.vue b/meshchatx/src/frontend/components/messages/MessagesPage.vue
index 9256e494..25f1a9fd 100644
--- a/meshchatx/src/frontend/components/messages/MessagesPage.vue
+++ b/meshchatx/src/frontend/components/messages/MessagesPage.vue
@@ -48,6 +48,7 @@
@delete-folder="onDeleteFolder"
@move-to-folder="onMoveToFolder"
@bulk-mark-as-read="onBulkMarkAsRead"
+ @mark-all-as-read="onMarkAllAsRead"
@bulk-delete="onBulkDelete"
@export-folders="onExportFolders"
@import-folders="onImportFolders"
@@ -1067,6 +1068,18 @@ export default {
ToastUtils.error(this.$t("messages.failed_mark_read"));
}
},
+ async onMarkAllAsRead() {
+ try {
+ await window.api.post("/api/v1/lxmf/conversations/bulk-mark-as-read", {
+ mark_all: true,
+ });
+ GlobalEmitter.emit("notifications-changed");
+ await this.getConversations();
+ ToastUtils.success(this.$t("messages.marked_all_read"));
+ } catch {
+ ToastUtils.error(this.$t("messages.failed_mark_read"));
+ }
+ },
async onBulkDelete(destination_hashes) {
try {
const confirmed = await DialogUtils.confirm(
diff --git a/meshchatx/src/frontend/components/messages/MessagesSidebar.vue b/meshchatx/src/frontend/components/messages/MessagesSidebar.vue
index d0100f1e..dba8c6dd 100644
--- a/meshchatx/src/frontend/components/messages/MessagesSidebar.vue
+++ b/meshchatx/src/frontend/components/messages/MessagesSidebar.vue
@@ -538,7 +538,11 @@
>
<ContextMenuItem @click="bulkMarkAsRead">
<MaterialDesignIcon icon-name="email-open-outline" class="size-4 text-gray-400" />
- Mark as Read
+ {{ $t("messages.mark_as_read") }}
+ </ContextMenuItem>
+ <ContextMenuItem @click="markAllAsRead">
+ <MaterialDesignIcon icon-name="email-check-outline" class="size-4 text-gray-400" />
+ {{ $t("messages.mark_all_as_read") }}
</ContextMenuItem>
<ContextMenuItem v-if="contextMenu.targetHash" @click="togglePinFromContextMenu">
<MaterialDesignIcon
@@ -918,6 +922,7 @@ export default {
"delete-folder",
"move-to-folder",
"bulk-mark-as-read",
+ "mark-all-as-read",
"bulk-delete",
"export-folders",
"import-folders",
@@ -1312,6 +1317,12 @@ export default {
this.folderMenu.show = false;
if (this.selectionMode) this.toggleSelectionMode();
},
+ markAllAsRead() {
+ this.$emit("mark-all-as-read");
+ this.contextMenu.show = false;
+ this.moveMenu.show = false;
+ this.folderMenu.show = false;
+ },
bulkDelete() {
const hashes = this.selectionMode ? Array.from(this.selectedHashes) : [this.contextMenu.targetHash];
this.$emit("bulk-delete", hashes);
diff --git a/meshchatx/src/frontend/components/settings/SettingsPage.vue b/meshchatx/src/frontend/components/settings/SettingsPage.vue
index 5b40c562..a58c3762 100644
--- a/meshchatx/src/frontend/components/settings/SettingsPage.vue
+++ b/meshchatx/src/frontend/components/settings/SettingsPage.vue
@@ -5074,8 +5074,7 @@ export default {
async exportMessages() {
try {
const response = await window.api.get("/api/v1/maintenance/messages/export");
- const messages = response.data.messages;
- const dataStr = JSON.stringify({ messages }, null, 2);
+ const dataStr = JSON.stringify(response.data, null, 2);
const blob = new Blob([dataStr], { type: "application/json" });
const exportFileDefaultName = `meshchat_messages_${new Date().toISOString().slice(0, 10)}.json`;
await DownloadUtils.downloadFile(exportFileDefaultName, blob);
diff --git a/meshchatx/src/frontend/js/messageImport.js b/meshchatx/src/frontend/js/messageImport.js
index 0ec5bcff..53b2233c 100644
--- a/meshchatx/src/frontend/js/messageImport.js
+++ b/meshchatx/src/frontend/js/messageImport.js
@@ -1,25 +1,28 @@
/**
- * Parse and import LXMF message export JSON.
+ * Parse and import LXMF message export JSON (v1 messages-only or v2 bundle).
*/
export function parseMessagesImportJson(text) {
const data = JSON.parse(text);
- const messages = Array.isArray(data) ? data : data?.messages;
- if (!Array.isArray(messages)) {
- throw new Error("Invalid file format");
+ if (Array.isArray(data)) {
+ return { messages: data };
}
- return messages;
+ if (data && typeof data === "object" && Array.isArray(data.messages)) {
+ return data;
+ }
+ throw new Error("Invalid file format");
}
export async function importMessagesFromText(text) {
- const messages = parseMessagesImportJson(text);
- const response = await window.api.post("/api/v1/maintenance/messages/import", {
- messages,
- });
+ const payload = parseMessagesImportJson(text);
+ const response = await window.api.post("/api/v1/maintenance/messages/import", payload);
return {
- messages,
- imported: response.data?.imported ?? messages.length,
+ payload,
+ imported: response.data?.imported ?? payload.messages?.length ?? 0,
skipped: response.data?.skipped ?? 0,
+ contacts_added: response.data?.contacts_added ?? 0,
+ display_names_imported: response.data?.display_names_imported ?? 0,
+ read_state_imported: response.data?.read_state_imported ?? 0,
};
}
@@ -30,5 +33,8 @@ export async function importMessagesFromFile(file) {
return {
imported: response.data?.imported ?? 0,
skipped: response.data?.skipped ?? 0,
+ contacts_added: response.data?.contacts_added ?? 0,
+ display_names_imported: response.data?.display_names_imported ?? 0,
+ read_state_imported: response.data?.read_state_imported ?? 0,
};
}
diff --git a/meshchatx/src/frontend/locales/de.json b/meshchatx/src/frontend/locales/de.json
index bd9dc44a..f21ffb5e 100644
--- a/meshchatx/src/frontend/locales/de.json
+++ b/meshchatx/src/frontend/locales/de.json
@@ -903,7 +903,7 @@
"clear_path_table_desc": "Entfernt alle zwischengespeicherten Reticulum-Transportpfade. Routen werden bei Bedarf neu entdeckt.",
"path_table_cleared": "Pfadtabelle geleert",
"export_messages": "Nachrichten exportieren",
- "export_messages_desc": "Alle Konversationen als JSON-Datei herunterladen.",
+ "export_messages_desc": "Konversationen als JSON herunterladen, inklusive Kontakte, Anzeigenamen und Lesestatus.",
"import_messages": "Nachrichten importieren",
"import_messages_desc": "Konversationen aus einer JSON-Datei wiederherstellen.",
"clear_confirm": "Sind Sie sicher? Diese Aktion kann nicht rückgängig gemacht werden.",
@@ -1609,7 +1609,10 @@
"failed_delete_folder": "Ordner konnte nicht gelöscht werden",
"moved_to_folder": "In Ordner verschoben",
"failed_move_folder": "Verschieben in Ordner fehlgeschlagen",
+ "mark_as_read": "Als gelesen markieren",
+ "mark_all_as_read": "Alle als gelesen markieren",
"marked_read": "Als gelesen markiert",
+ "marked_all_read": "Alle Konversationen als gelesen markiert",
"failed_mark_read": "Als gelesen markieren fehlgeschlagen",
"conversations_deleted": "Unterhaltungen gelöscht",
"failed_delete_conversations": "Unterhaltungen konnten nicht gelöscht werden",
diff --git a/meshchatx/src/frontend/locales/en.json b/meshchatx/src/frontend/locales/en.json
index ba734f7e..ad224d56 100644
--- a/meshchatx/src/frontend/locales/en.json
+++ b/meshchatx/src/frontend/locales/en.json
@@ -903,7 +903,7 @@
"clear_path_table_desc": "Drop all cached Reticulum transport paths. Routes will be re-discovered as needed.",
"path_table_cleared": "Path table cleared",
"export_messages": "Export Messages",
- "export_messages_desc": "Download all conversations as a JSON file.",
+ "export_messages_desc": "Download conversations as JSON, including contacts, display names, and read state.",
"import_messages": "Import Messages",
"import_messages_desc": "Restore conversations from a JSON file.",
"clear_confirm": "Are you sure? This action cannot be undone.",
@@ -1553,7 +1553,10 @@
"failed_delete_folder": "Failed to delete folder",
"moved_to_folder": "Moved to folder",
"failed_move_folder": "Failed to move to folder",
+ "mark_as_read": "Mark as Read",
+ "mark_all_as_read": "Mark all as read",
"marked_read": "Marked as read",
+ "marked_all_read": "All conversations marked as read",
"failed_mark_read": "Failed to mark as read",
"conversations_deleted": "Conversations deleted",
"failed_delete_conversations": "Failed to delete conversations",
diff --git a/meshchatx/src/frontend/locales/es.json b/meshchatx/src/frontend/locales/es.json
index a2bc7f94..cb92de34 100644
--- a/meshchatx/src/frontend/locales/es.json
+++ b/meshchatx/src/frontend/locales/es.json
@@ -903,7 +903,7 @@
"clear_path_table_desc": "Elimina todas las rutas de transporte Reticulum en caché. Las rutas se redescubrirán según sea necesario.",
"path_table_cleared": "Tabla de rutas borrada",
"export_messages": "Mensajes de exportación",
- "export_messages_desc": "Descargue todas las conversaciones como archivo JSON.",
+ "export_messages_desc": "Descarga las conversaciones como JSON, incluyendo contactos, nombres y estado de lectura.",
"import_messages": "Importar mensajes",
"import_messages_desc": "Restaurar conversaciones de un archivo JSON.",
"clear_confirm": "¿Estás seguro? Esta acción no se puede deshacer.",
@@ -1553,7 +1553,10 @@
"failed_delete_folder": "Error al eliminar carpeta",
"moved_to_folder": "Movedo a la carpeta",
"failed_move_folder": "Error al mover a carpeta",
+ "mark_as_read": "Marcar como leído",
+ "mark_all_as_read": "Marcar todo como leído",
"marked_read": "Marcado como leído",
+ "marked_all_read": "Todas las conversaciones marcadas como leídas",
"failed_mark_read": "Error al marcar como leído",
"conversations_deleted": "Conversaciones eliminadas",
"failed_delete_conversations": "Fallado para borrar conversaciones",
diff --git a/meshchatx/src/frontend/locales/fi.json b/meshchatx/src/frontend/locales/fi.json
index 9d5f45f0..b93fe6bf 100644
--- a/meshchatx/src/frontend/locales/fi.json
+++ b/meshchatx/src/frontend/locales/fi.json
@@ -903,7 +903,7 @@
"clear_path_table_desc": "Unohda välimuistissa olevat Reticulum-välityksen polut. Reitit löydetään uudelleen tarpeen mukaan.",
"path_table_cleared": "Polkutaulukko tyhjätty",
"export_messages": "Vie viestejä",
- "export_messages_desc": "Lataa kaikki keskustelut JSON-tiedostona.",
+ "export_messages_desc": "Lataa keskustelut JSON-tiedostona, mukaan lukien yhteystiedot, nimet ja lukutila.",
"import_messages": "Tuo viestejä",
"import_messages_desc": "Palauta keskustelut JSON-tiedostosta.",
"clear_confirm": "Oletko varma? Toimintoa ei voi peruuttaa.",
@@ -1553,7 +1553,10 @@
"failed_delete_folder": "Kansion poisto epäonnistui",
"moved_to_folder": "Siirretty kansioon",
"failed_move_folder": "Siirto kansioon epäonnistui",
+ "mark_as_read": "Merkitse luetuksi",
+ "mark_all_as_read": "Merkitse kaikki luetuiksi",
"marked_read": "Merkitty luetuksi",
+ "marked_all_read": "Kaikki keskustelut merkitty luetuiksi",
"failed_mark_read": "Luetuksi merkitseminen epäonnistui",
"conversations_deleted": "Keskustelut poistettu",
"failed_delete_conversations": "Keskustelujen poisto epäonnistui",
diff --git a/meshchatx/src/frontend/locales/fr.json b/meshchatx/src/frontend/locales/fr.json
index bd590e01..da180daa 100644
--- a/meshchatx/src/frontend/locales/fr.json
+++ b/meshchatx/src/frontend/locales/fr.json
@@ -903,7 +903,7 @@
"clear_path_table_desc": "Supprime tous les chemins de transport Reticulum en cache. Les routes seront redécouvertes au besoin.",
"path_table_cleared": "Table de chemins effacée",
"export_messages": "Exporter des messages",
- "export_messages_desc": "Télécharger toutes les conversations sous forme de fichier JSON.",
+ "export_messages_desc": "Télécharger les conversations en JSON, avec contacts, noms affichés et état de lecture.",
"import_messages": "Importer des messages",
"import_messages_desc": "Restaurer les conversations à partir d'un fichier JSON.",
"clear_confirm": "Tu es sûr ? Cette action ne peut être annulée.",
@@ -1553,7 +1553,10 @@
"failed_delete_folder": "Impossible de supprimer le dossier",
"moved_to_folder": "Déplacé dans le dossier",
"failed_move_folder": "Impossible de se déplacer dans le dossier",
+ "mark_as_read": "Marquer comme lu",
+ "mark_all_as_read": "Tout marquer comme lu",
"marked_read": "Marqué comme suit:",
+ "marked_all_read": "Toutes les conversations marquées comme lues",
"failed_mark_read": "Échec du marquage comme suit:",
"conversations_deleted": "Conversations supprimées",
"failed_delete_conversations": "Impossible de supprimer les conversations",
diff --git a/meshchatx/src/frontend/locales/it.json b/meshchatx/src/frontend/locales/it.json
index 6e3a8047..a2a3511f 100644
--- a/meshchatx/src/frontend/locales/it.json
+++ b/meshchatx/src/frontend/locales/it.json
@@ -903,7 +903,7 @@
"clear_path_table_desc": "Elimina tutti i percorsi di trasporto Reticulum memorizzati. I percorsi verranno riscoperti quando necessario.",
"path_table_cleared": "Tabella percorsi svuotata",
"export_messages": "Esporta Messaggi",
- "export_messages_desc": "Scarica tutte le conversazioni come file JSON.",
+ "export_messages_desc": "Scarica le conversazioni come JSON, inclusi contatti, nomi visualizzati e stato di lettura.",
"import_messages": "Importa Messaggi",
"import_messages_desc": "Ripristina le conversazioni da un file JSON.",
"clear_confirm": "Sei sicuro? Questa azione non può essere annullata.",
@@ -1609,7 +1609,10 @@
"failed_delete_folder": "Impossibile eliminare la cartella",
"moved_to_folder": "Spostato nella cartella",
"failed_move_folder": "Impossibile spostare nella cartella",
+ "mark_as_read": "Segna come letto",
+ "mark_all_as_read": "Segna tutto come letto",
"marked_read": "Segnato come letto",
+ "marked_all_read": "Tutte le conversazioni segnate come lette",
"failed_mark_read": "Impossibile segnare come letto",
"conversations_deleted": "Conversazioni eliminate",
"failed_delete_conversations": "Impossibile eliminare le conversazioni",
diff --git a/meshchatx/src/frontend/locales/nl.json b/meshchatx/src/frontend/locales/nl.json
index 6af7c820..a6566b76 100644
--- a/meshchatx/src/frontend/locales/nl.json
+++ b/meshchatx/src/frontend/locales/nl.json
@@ -903,7 +903,7 @@
"clear_path_table_desc": "Verwijdert alle gecachte Reticulum-transportpaden. Routes worden opnieuw ontdekt wanneer nodig.",
"path_table_cleared": "Padtabel gewist",
"export_messages": "Berichten exporteren",
- "export_messages_desc": "Alle gesprekken downloaden als een JSON-bestand.",
+ "export_messages_desc": "Download gesprekken als JSON, inclusief contacten, weergavenamen en leesstatus.",
"import_messages": "Berichten importeren",
"import_messages_desc": "Gesprekken uit een JSON-bestand herstellen.",
"clear_confirm": "Weet je het zeker? Deze actie kan niet ongedaan worden gemaakt.",
@@ -1553,7 +1553,10 @@
"failed_delete_folder": "Verwijderen van map is mislukt",
"moved_to_folder": "Verplaatst naar map",
"failed_move_folder": "Verplaatsen naar map is mislukt",
+ "mark_as_read": "Markeren als gelezen",
+ "mark_all_as_read": "Alles markeren als gelezen",
"marked_read": "Gemarkeerd als gelezen",
+ "marked_all_read": "Alle gesprekken gemarkeerd als gelezen",
"failed_mark_read": "Markeren als gelezen is mislukt",
"conversations_deleted": "Gesprekken verwijderd",
"failed_delete_conversations": "Kon gesprekken niet verwijderen",
diff --git a/meshchatx/src/frontend/locales/ru.json b/meshchatx/src/frontend/locales/ru.json
index 6a10625a..5a85f079 100644
--- a/meshchatx/src/frontend/locales/ru.json
+++ b/meshchatx/src/frontend/locales/ru.json
@@ -903,7 +903,7 @@
"clear_path_table_desc": "Удаляет все кэшированные транспортные пути Reticulum. Маршруты будут обнаружены заново по мере необходимости.",
"path_table_cleared": "Таблица путей очищена",
"export_messages": "Экспортировать сообщения",
- "export_messages_desc": "Загрузить все разговоры в виде JSON-файла.",
+ "export_messages_desc": "Скачать переписки в JSON вместе с контактами, именами и статусом прочтения.",
"import_messages": "Импортировать сообщения",
"import_messages_desc": "Восстановить разговоры из JSON-файла.",
"clear_confirm": "Вы уверены? Это действие нельзя отменить.",
@@ -1609,7 +1609,10 @@
"failed_delete_folder": "Не удалось удалить папку",
"moved_to_folder": "Перемещено в папку",
"failed_move_folder": "Не удалось переместить в папку",
+ "mark_as_read": "Отметить как прочитанное",
+ "mark_all_as_read": "Отметить все как прочитанные",
"marked_read": "Отмечено как прочитанное",
+ "marked_all_read": "Все переписки отмечены как прочитанные",
"failed_mark_read": "Не удалось отметить как прочитанное",
"conversations_deleted": "Разговоры удалены",
"failed_delete_conversations": "Не удалось удалить разговоры",
diff --git a/meshchatx/src/frontend/locales/zh.json b/meshchatx/src/frontend/locales/zh.json
index fca935be..7e2a7db0 100644
--- a/meshchatx/src/frontend/locales/zh.json
+++ b/meshchatx/src/frontend/locales/zh.json
@@ -903,7 +903,7 @@
"clear_path_table_desc": "删除所有缓存的 Reticulum 传输路径。需要时将重新发现路由。",
"path_table_cleared": "路径表已清除",
"export_messages": "导出消息",
- "export_messages_desc": "将所有对话下载为 JSON 文件。",
+ "export_messages_desc": "将对话下载为 JSON,包含联系人、显示名称和已读状态。",
"import_messages": "导入消息",
"import_messages_desc": "从 JSON 文件恢复对话。",
"clear_confirm": "您确定吗?此操作无法撤销。",
@@ -1553,7 +1553,10 @@
"failed_delete_folder": "删除文件夹失败",
"moved_to_folder": "已移动到文件夹",
"failed_move_folder": "移动到文件夹失败",
+ "mark_as_read": "标记为已读",
+ "mark_all_as_read": "全部标记为已读",
"marked_read": "标记为已读",
+ "marked_all_read": "已将全部对话标记为已读",
"failed_mark_read": "标记为已读失败",
"conversations_deleted": "对话已删除",
"failed_delete_conversations": "删除对话失败",
diff --git a/tests/backend/test_android_ble_permissions.py b/tests/backend/test_android_ble_permissions.py
index db489ad0..096cd70b 100644
--- a/tests/backend/test_android_ble_permissions.py
+++ b/tests/backend/test_android_ble_permissions.py
@@ -36,5 +36,7 @@ def test_android_optional_import_typings_exist_for_basedpyright():
TYPINGS / "able" / "__init__.pyi",
TYPINGS / "jnius" / "__init__.pyi",
)
- missing = [str(path.relative_to(REPO_ROOT)) for path in required if not path.is_file()]
+ missing = [
+ str(path.relative_to(REPO_ROOT)) for path in required if not path.is_file()
+ ]
assert missing == []
diff --git a/tests/backend/test_messages_export_bundle.py b/tests/backend/test_messages_export_bundle.py
new file mode 100644
index 00000000..92d0e80f
--- /dev/null
+++ b/tests/backend/test_messages_export_bundle.py
@@ -0,0 +1,229 @@
+# SPDX-License-Identifier: 0BSD
+
+import json
+import shutil
+import tempfile
+from unittest.mock import MagicMock, patch
+
+import pytest
+import RNS
+
+from meshchatx.meshchat import ReticulumMeshChat
+from meshchatx.src.backend.message_export_bundle import (
+ MESSAGE_EXPORT_FORMAT,
+ build_messages_export_bundle,
+ import_messages_export_bundle,
+)
+
+
+def _make_json_request(body):
+ request = MagicMock()
+
+ async def _json():
+ return body
+
+ request.json = _json
+ return request
+
+
+@pytest.fixture
+def temp_dir():
+ dir_path = tempfile.mkdtemp()
+ yield dir_path
+ shutil.rmtree(dir_path)
+
+
+@pytest.fixture
+def mock_rns_minimal():
+ with (
+ patch("RNS.Reticulum") as mock_rns,
+ patch("RNS.Transport"),
+ patch("LXMF.LXMRouter"),
+ patch("meshchatx.meshchat.get_file_path", return_value="/tmp/mock_path"),
+ ):
+ mock_rns_instance = mock_rns.return_value
+ mock_rns_instance.configpath = "/tmp/mock_config"
+ mock_rns_instance.is_connected_to_shared_instance = False
+ mock_rns_instance.transport_enabled.return_value = True
+
+ mock_id = MagicMock(spec=RNS.Identity)
+ mock_id.hash = b"test_hash_32_bytes_long_01234567"
+ mock_id.hexhash = mock_id.hash.hex()
+ mock_id.get_private_key.return_value = b"test_private_key"
+ yield mock_id
+
+
+def _seed_message(app, *, msg_hash, peer, incoming=1, timestamp=1000.0, content="Hi"):
+ app.database.messages.upsert_lxmf_message(
+ {
+ "hash": msg_hash,
+ "source_hash": peer if incoming else "local",
+ "destination_hash": "local" if incoming else peer,
+ "peer_hash": peer,
+ "state": "delivered",
+ "progress": 1.0,
+ "is_incoming": incoming,
+ "method": "delivery",
+ "delivery_attempts": 0,
+ "next_delivery_attempt_at": None,
+ "title": None,
+ "content": content,
+ "fields": None,
+ "timestamp": timestamp,
+ "rssi": None,
+ "snr": None,
+ "quality": None,
+ "is_spam": 0,
+ "reply_to_hash": None,
+ "attachments_stripped": None,
+ "path_hops_at_send": None,
+ "path_interface_at_send": None,
+ "path_finding_measure": None,
+ "path_row_hash_hex": None,
+ }
+ )
+
+
+@pytest.mark.asyncio
+async def test_messages_export_includes_contacts_names_and_read_state(
+ mock_rns_minimal, temp_dir
+):
+ with patch("meshchatx.meshchat.generate_ssl_certificate"):
+ app = ReticulumMeshChat(
+ identity=mock_rns_minimal,
+ storage_dir=temp_dir,
+ reticulum_config_dir=temp_dir,
+ )
+ peer = "a" * 32
+ _seed_message(app, msg_hash="msg1", peer=peer, timestamp=1500.0)
+ app.database.contacts.add_contact("Alice", peer, lxmf_address=peer)
+ app.database.announces.upsert_custom_display_name(peer, "Alice Custom")
+ app.database.messages.mark_conversation_as_read(peer)
+ app.database.messages.mark_notification_as_viewed(peer)
+
+ handler = None
+ for route in app.get_routes():
+ if (
+ route.path == "/api/v1/maintenance/messages/export"
+ and route.method == "GET"
+ ):
+ handler = route.handler
+ break
+ assert handler is not None
+ response = await handler(MagicMock())
+ data = json.loads(response.body)
+
+ assert data["format"] == MESSAGE_EXPORT_FORMAT
+ assert len(data["messages"]) == 1
+ assert any(c["name"] == "Alice" for c in data["contacts"])
+ assert any(
+ d["destination_hash"] == peer and d["display_name"] == "Alice Custom"
+ for d in data["display_names"]
+ )
+ assert any(
+ r["destination_hash"] == peer for r in data["conversation_read_state"]
+ )
+ assert any(
+ r["destination_hash"] == peer for r in data["notification_viewed_state"]
+ )
+
+
+@pytest.mark.asyncio
+async def test_messages_import_restores_contacts_names_and_read_state(
+ mock_rns_minimal, temp_dir
+):
+ with patch("meshchatx.meshchat.generate_ssl_certificate"):
+ app = ReticulumMeshChat(
+ identity=mock_rns_minimal,
+ storage_dir=temp_dir,
+ reticulum_config_dir=temp_dir,
+ )
+ peer = "b" * 32
+ _seed_message(app, msg_hash="msg-export", peer=peer, timestamp=2000.0)
+ app.database.contacts.add_contact("Bob", peer, lxmf_address=peer)
+ app.database.announces.upsert_custom_display_name(peer, "Bob Name")
+ app.database.messages.mark_conversation_as_read(peer)
+ app.database.messages.mark_notification_as_viewed(peer)
+
+ messages = [dict(m) for m in app.database.messages.get_all_lxmf_messages()]
+ bundle = build_messages_export_bundle(app.database, messages)
+
+ # Wipe identity-scoped tables by importing into a fresh app DB path is hard
+ # so clear via DAO deletes then re-import.
+ app.database.messages.delete_all_lxmf_messages()
+ app.database.provider.execute("DELETE FROM contacts")
+ app.database.provider.execute("DELETE FROM custom_destination_display_names")
+ app.database.provider.execute("DELETE FROM lxmf_conversation_read_state")
+ app.database.provider.execute("DELETE FROM notification_viewed_state")
+
+ result = import_messages_export_bundle(app.database, bundle)
+ assert result["ok"] is True
+ assert result["imported"] == 1
+ assert result["contacts_added"] == 1
+ assert result["display_names_imported"] == 1
+ assert result["read_state_imported"] >= 1
+
+ contacts = app.database.contacts.get_contacts()
+ assert any(c["name"] == "Bob" for c in contacts)
+ assert app.database.announces.get_custom_display_name(peer) == "Bob Name"
+ assert app.database.messages.is_conversation_unread(peer) is False
+
+
+@pytest.mark.asyncio
+async def test_bulk_mark_all_as_read(mock_rns_minimal, temp_dir):
+ with patch("meshchatx.meshchat.generate_ssl_certificate"):
+ app = ReticulumMeshChat(
+ identity=mock_rns_minimal,
+ storage_dir=temp_dir,
+ reticulum_config_dir=temp_dir,
+ )
+ peer = "c" * 32
+ _seed_message(app, msg_hash="msg-unread", peer=peer, timestamp=3000.0)
+ assert app.database.messages.is_conversation_unread(peer) is True
+
+ handler = None
+ for route in app.get_routes():
+ if (
+ route.path == "/api/v1/lxmf/conversations/bulk-mark-as-read"
+ and route.method == "POST"
+ ):
+ handler = route.handler
+ break
+ assert handler is not None
+ response = await handler(_make_json_request({"mark_all": True}))
+ assert response.status == 200
+ assert app.database.messages.is_conversation_unread(peer) is False
+
+
+@pytest.mark.asyncio
+async def test_legacy_messages_only_import_still_works(mock_rns_minimal, temp_dir):
+ with patch("meshchatx.meshchat.generate_ssl_certificate"):
+ app = ReticulumMeshChat(
+ identity=mock_rns_minimal,
+ storage_dir=temp_dir,
+ reticulum_config_dir=temp_dir,
+ )
+ peer = "d" * 32
+ payload = {
+ "messages": [
+ {
+ "hash": "legacy1",
+ "source_hash": peer,
+ "destination_hash": "local",
+ "peer_hash": peer,
+ "state": "delivered",
+ "progress": 1.0,
+ "is_incoming": 1,
+ "method": "delivery",
+ "delivery_attempts": 0,
+ "title": None,
+ "content": "legacy",
+ "fields": None,
+ "timestamp": 1111.0,
+ "is_spam": 0,
+ }
+ ]
+ }
+ result = import_messages_export_bundle(app.database, payload)
+ assert result["ok"] is True
+ assert result["imported"] == 1
diff --git a/tests/frontend/CspBootTheme.test.js b/tests/frontend/CspBootTheme.test.js
index 597d0a69..690d41da 100644
--- a/tests/frontend/CspBootTheme.test.js
+++ b/tests/frontend/CspBootTheme.test.js
@@ -52,9 +52,7 @@ describe("main app CSP and boot theme", () => {
continue;
}
const rel = src.replace(/^\//, "");
- expect(existsSync(resolve(PUBLIC_DIR, rel)), `missing public asset for ${src}`).toBe(
- true,
- );
+ expect(existsSync(resolve(PUBLIC_DIR, rel)), `missing public asset for ${src}`).toBe(true);
}
});
diff --git a/tests/frontend/MessagesPage.test.js b/tests/frontend/MessagesPage.test.js
index 1e1eaae4..2418af2a 100644
--- a/tests/frontend/MessagesPage.test.js
+++ b/tests/frontend/MessagesPage.test.js
@@ -546,6 +546,24 @@ describe("MessagesPage.vue", () => {
expect(GlobalEmitter.emit).toHaveBeenCalledWith("notifications-changed");
});
+ it("onMarkAllAsRead posts mark_all and refreshes conversations", async () => {
+ const wrapper = mountMessagesPage();
+ await wrapper.vm.$nextTick();
+ axiosMock.post.mockResolvedValue({ data: {} });
+ axiosMock.get.mockResolvedValue({ data: { conversations: [] } });
+ GlobalEmitter.emit.mockClear();
+ const getConversations = vi.spyOn(wrapper.vm, "getConversations").mockResolvedValue(undefined);
+
+ await wrapper.vm.onMarkAllAsRead();
+ await wrapper.vm.$nextTick();
+
+ expect(axiosMock.post).toHaveBeenCalledWith("/api/v1/lxmf/conversations/bulk-mark-as-read", {
+ mark_all: true,
+ });
+ expect(GlobalEmitter.emit).toHaveBeenCalledWith("notifications-changed");
+ expect(getConversations).toHaveBeenCalled();
+ });
+
it("onBulkMarkAsRead does not notify bell when server rejects", async () => {
const wrapper = mountMessagesPage();
await wrapper.vm.$nextTick();
diff --git a/tests/frontend/messageImport.test.js b/tests/frontend/messageImport.test.js
new file mode 100644
index 00000000..51965678
--- /dev/null
+++ b/tests/frontend/messageImport.test.js
@@ -0,0 +1,60 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { parseMessagesImportJson, importMessagesFromText } from "../../meshchatx/src/frontend/js/messageImport.js";
+
+describe("messageImport", () => {
+ beforeEach(() => {
+ window.api = {
+ post: vi.fn().mockResolvedValue({
+ data: {
+ imported: 2,
+ skipped: 0,
+ contacts_added: 1,
+ display_names_imported: 1,
+ read_state_imported: 2,
+ },
+ }),
+ };
+ });
+
+ it("parses legacy messages-only JSON", () => {
+ const payload = parseMessagesImportJson(JSON.stringify({ messages: [{ hash: "a" }] }));
+ expect(payload.messages).toHaveLength(1);
+ });
+
+ it("parses bare message arrays", () => {
+ const payload = parseMessagesImportJson(JSON.stringify([{ hash: "a" }]));
+ expect(payload.messages).toHaveLength(1);
+ });
+
+ it("parses v2 bundles with contacts and read state", () => {
+ const payload = parseMessagesImportJson(
+ JSON.stringify({
+ format: "meshchatx/messages/v2",
+ messages: [{ hash: "a" }],
+ contacts: [{ name: "Alice", remote_identity_hash: "aa" }],
+ display_names: [{ destination_hash: "aa", display_name: "Alice" }],
+ conversation_read_state: [{ destination_hash: "aa", last_read_at: "2026-01-01" }],
+ })
+ );
+ expect(payload.contacts).toHaveLength(1);
+ expect(payload.display_names).toHaveLength(1);
+ expect(payload.conversation_read_state).toHaveLength(1);
+ });
+
+ it("posts full v2 payload on importMessagesFromText", async () => {
+ const text = JSON.stringify({
+ format: "meshchatx/messages/v2",
+ messages: [{ hash: "a" }],
+ contacts: [{ name: "Alice", remote_identity_hash: "aa" }],
+ });
+ const result = await importMessagesFromText(text);
+ expect(window.api.post).toHaveBeenCalledWith("/api/v1/maintenance/messages/import", {
+ format: "meshchatx/messages/v2",
+ messages: [{ hash: "a" }],
+ contacts: [{ name: "Alice", remote_identity_hash: "aa" }],
+ });
+ expect(result.imported).toBe(2);
+ expect(result.contacts_added).toBe(1);
+ expect(result.read_state_imported).toBe(2);
+ });
+});
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────